use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
};
use std::{
env, fs, io,
path::{Path, PathBuf},
time::Duration,
};
struct TreeEntry {
entry: fs::DirEntry,
depth: usize,
}
struct App {
should_quit: bool,
recursive_view: bool,
current_path: PathBuf,
current_entries: Vec<TreeEntry>,
current_selected: ListState,
parent_entries: Vec<fs::DirEntry>,
parent_selected: ListState,
}
impl App {
fn new() -> Self {
let mut app = Self {
should_quit: false,
recursive_view: false,
current_path: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
current_entries: Vec::new(),
current_selected: ListState::default(),
parent_entries: Vec::new(),
parent_selected: ListState::default(),
};
app.current_selected.select(Some(0));
app.update_panels();
app
}
fn read_dir_entries(path: &Path) -> io::Result<Vec<fs::DirEntry>> {
let mut entries = fs::read_dir(path)?
.filter_map(|res| res.ok())
.collect::<Vec<_>>();
entries.sort_by_key(|entry| {
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
(!is_dir, entry.file_name())
});
Ok(entries)
}
fn build_recursive_tree(
path: &Path,
current_depth: usize,
entries_list: &mut Vec<TreeEntry>,
) -> io::Result<()> {
let entries = Self::read_dir_entries(path)?;
for entry in entries {
let path = entry.path();
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
entries_list.push(TreeEntry {
entry,
depth: current_depth,
});
if is_dir {
let _ = Self::build_recursive_tree(&path, current_depth + 1, entries_list);
}
}
Ok(())
}
fn update_panels(&mut self) {
self.current_entries.clear();
if self.recursive_view {
let _ = Self::build_recursive_tree(&self.current_path, 0, &mut self.current_entries);
} else {
if let Ok(entries) = Self::read_dir_entries(&self.current_path) {
for entry in entries {
self.current_entries.push(TreeEntry { entry, depth: 0 });
}
}
}
if self.current_entries.is_empty() {
self.current_selected.select(None);
} else {
// Select 0, or clamp to new max
let new_max = self.current_entries.len() - 1;
if let Some(selected) = self.current_selected.selected() {
if selected > new_max {
self.current_selected.select(Some(new_max));
}
} else {
self.current_selected.select(Some(0));
}
}
if let Some(parent_path) = self.current_path.parent() {
self.parent_entries = Self::read_dir_entries(parent_path).unwrap_or_default();
} else {
self.parent_entries.clear();
}
self.parent_selected.select(Some(0));
}
fn toggle_recursive_view(&mut self) {
self.recursive_view = !self.recursive_view;
self.update_panels();
}
fn toggle_fold(&mut self) {
let selected_idx = match self.current_selected.selected() {
Some(i) => i,
None => return,
};
let (current_path, current_depth) = {
let selected_item = &self.current_entries[selected_idx];
if !selected_item
.entry
.file_type()
.map(|ft| ft.is_dir())
.unwrap_or(false)
{
return;
}
(selected_item.entry.path(), selected_item.depth)
};
let is_unfolded = self
.current_entries
.get(selected_idx + 1)
.map_or(false, |next_item| next_item.depth > current_depth);
if is_unfolded {
let end_range = self
.current_entries
.iter()
.skip(selected_idx + 1)
.position(|item| item.depth <= current_depth)
.map_or(self.current_entries.len(), |i| i + selected_idx + 1);
if end_range > selected_idx + 1 {
self.current_entries.drain(selected_idx + 1..end_range);
}
} else {
let mut new_entries = Vec::new();
let _ = Self::build_recursive_tree(¤t_path, current_depth + 1, &mut new_entries);
if !new_entries.is_empty() {
self.current_entries
.splice(selected_idx + 1..selected_idx + 1, new_entries);
}
}
}
fn enter_directory(&mut self) {
if let Some(selected_idx) = self.current_selected.selected() {
if let Some(tree_entry) = self.current_entries.get(selected_idx) {
if tree_entry
.entry
.file_type()
.map(|ft| ft.is_dir())
.unwrap_or(false)
{
self.current_path = tree_entry.entry.path();
self.recursive_view = false;
self.update_panels();
}
}
}
}
fn leave_directory(&mut self) {
if self.current_path.pop() {
self.recursive_view = false;
self.update_panels();
}
}
fn select_next(&mut self) {
let i = match self.current_selected.selected() {
Some(i) => {
if i >= self.current_entries.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.current_selected.select(Some(i));
}
fn select_previous(&mut self) {
let i = match self.current_selected.selected() {
Some(i) => {
if i == 0 {
if self.current_entries.is_empty() {
0
} else {
self.current_entries.len() - 1
}
} else {
i - 1
}
}
None => 0,
};
self.current_selected.select(Some(i));
}
fn get_selected_entry(&self) -> Option<&fs::DirEntry> {
self.current_selected
.selected()
.and_then(|i| self.current_entries.get(i))
.map(|tree_entry| &tree_entry.entry) // Get the inner entry
}
}
fn main() -> Result<(), io::Error> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
let res = run_app(&mut terminal, &mut app);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
Ok(())
}
fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<()> {
loop {
terminal.draw(|f| ui::<B>(f, app))?;
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('e') => app.toggle_recursive_view(),
KeyCode::Char('t') => app.toggle_fold(),
KeyCode::Char('j') | KeyCode::Down => app.select_next(),
KeyCode::Char('k') | KeyCode::Up => app.select_previous(),
KeyCode::Char('h') | KeyCode::Backspace | KeyCode::Left => {
app.leave_directory()
}
KeyCode::Char('l') | KeyCode::Enter | KeyCode::Right => app.enter_directory(),
_ => {}
}
}
}
if app.should_quit {
return Ok(());
}
}
}
fn ui<B: Backend>(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(f.size());
let header_chunk = chunks[0];
let content_chunk = chunks[1];
let path_str = app.current_path.to_string_lossy();
let header =
Paragraph::new(path_str.as_ref()).style(Style::default().bg(Color::Blue).fg(Color::White));
f.render_widget(header, header_chunk);
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25),
Constraint::Percentage(40),
Constraint::Percentage(35),
])
.split(content_chunk);
let parent_items: Vec<ListItem> = app
.parent_entries
.iter()
.map(|entry| format_entry_flat(entry))
.collect();
let parent_list = List::new(parent_items)
.block(Block::default().borders(Borders::ALL).title("Parent"))
.style(Style::default().fg(Color::DarkGray));
f.render_stateful_widget(parent_list, content_chunks[0], &mut app.parent_selected);
let title = if app.recursive_view {
"Current (Recursive 'e')"
} else {
"Current (Flat 'e')"
};
let current_items: Vec<ListItem> = app
.current_entries
.iter()
.map(|tree_entry| format_entry_tree(tree_entry))
.collect();
let current_list = List::new(current_items)
.block(Block::default().borders(Borders::ALL).title(title))
.highlight_style(
Style::default()
.bg(Color::LightBlue)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
);
f.render_stateful_widget(current_list, content_chunks[1], &mut app.current_selected);
let preview_text = match app.get_selected_entry() {
Some(entry) => get_entry_info(entry),
None => "No item selected".to_string(),
};
let preview = Paragraph::new(preview_text)
.block(Block::default().borders(Borders::ALL).title("Preview"))
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(preview, content_chunks[2]);
}
fn format_entry_flat(entry: &fs::DirEntry) -> ListItem {
let file_name = entry.file_name().to_string_lossy().to_string();
let metadata = entry.metadata().ok();
let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let (icon, style) = if is_dir {
("📁 ", Style::default().fg(Color::Cyan))
} else {
("📄 ", Style::default().fg(Color::White))
};
ListItem::new(format!("{icon}{file_name}")).style(style)
}
fn format_entry_tree(tree_entry: &TreeEntry) -> ListItem {
let entry = &tree_entry.entry;
let file_name = entry.file_name().to_string_lossy().to_string();
let metadata = entry.metadata().ok();
let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let (icon, style) = if is_dir {
("📁 ", Style::default().fg(Color::Cyan)) // Directory
} else {
("📄 ", Style::default().fg(Color::White)) // File
};
let indent = " ".repeat(tree_entry.depth);
ListItem::new(format!("{indent}{icon}{file_name}")).style(style)
}
fn get_entry_info(entry: &fs::DirEntry) -> String {
let mut info = String::new();
info.push_str(&format!("Name: {}\n", entry.file_name().to_string_lossy()));
if let Ok(metadata) = entry.metadata() {
let file_type = if metadata.is_dir() {
"Directory"
} else if metadata.is_file() {
"File"
} else if metadata.is_symlink() {
"Symlink"
} else {
"Other"
};
info.push_str(&format!("Type: {}\n", file_type));
if metadata.is_file() {
info.push_str(&format!("Size: {}\n", format_size(metadata.len())));
}
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::SystemTime::UNIX_EPOCH) {
info.push_str(&format!("Modified (epoch): {}\n", duration.as_secs()));
}
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = metadata.permissions();
info.push_str(&format!("Perms: {:o}\n", perms.mode() & 0o777));
}
} else {
info.push_str("Could not read metadata.\n");
}
info
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}